<?php

/**
 * Provides database access.
 *
 * @version  $Id: database.php 2 2009-10-02 23:06:43Z perfilev $
 * @package  System
 */

class DatabaseException extends Exception {}

class Database
{
	/**
	 * @var  array  Database instances.
	 */
	public static $instances = array();

	/**
	 * @var  integer  Database queries counter.
	 */
	public static $queriesCount = 0;

	/**
	 * Returns a instance of Database.
	 *
	 * @param   string $name   Instance name.
	 * @param   array  $config Configuration array.
	 * @return  Database
	 */
	public static function instance($name = 'default', array $config = null)
	{
		if (false == isset(self::$instances[$name])) {
			if (empty($config)) {
				$config = System::config('database.' . $name);
			}
			self::$instances[$name] = new Database($config);
		}
		return self::$instances[$name];
	}

	/**
	 * @var  MySQLi  Database connection.
	 */
	protected $link;

	/**
	 * @var  string  Last executed query.
	 */
	protected $lastQuery;

	/**
	 * @var  array  Class configuration.
	 */
	protected $config = array(
		'hostname'   => null,
		'username'   => null,
		'password'   => null,
		'database'   => null,
		'port'       => null,
		'socket'     => null,
		'charset'    => 'utf8',
	);

	/**
	 * Sets up the database configuration and connects to the server.
	 *
	 * @param  array  $config  Configuration array.
	 * @return this
	 * @throws DatabaseException
	 */
	public function __construct(array $config)
	{
		$this->config = array_merge($this->config, $config);
		$this->link = mysqli_connect(
			$this->config['hostname'],
			$this->config['username'],
			$this->config['password'],
			$this->config['database'],
			$this->config['port'],
			$this->config['socket']
		);
		if (false === $this->link) {
			throw new DatabaseException('Connection failed: ' . mysqli_connect_error());
		}
		if (false === mysqli_set_charset($this->link, $this->config['charset'])) {
			throw new DatabaseException('Character set failed: ' . mysqli_error($this->link));
		}
	}

	/**
	 * Runs a query and returns the result.
	 *
	 * @param  string  $sql   SQL query to execute.
	 * @param  mixed   $binds Query paramters.
	 * @return Database_Result
	 * @throws DatabaseException
	 */
	public function query($sql = '', $binds = null)
	{
		if ('' == $sql) return null;
		
		if (null !== $binds) {
			$sql = $this->compileQuery($sql, $binds);
		}

		$this->lastQuery = $sql;
		self::$queriesCount++;

		if (false === $result = mysqli_query($this->link, $sql)) {
			throw new DatabaseException(mysqli_error($this->link) . '. SQL: ' . $sql, mysqli_errno($this->link));
		}
		return new Database_Result($this->link, $result);
	}

	/**
	 * Combine a SQL statement with the bind values. Used for safe queries.
	 *
	 * @param  string  $sql    Query to bind to the values.
	 * @param  array   $binds  Array of values to bind to the query.
	 * @return string
	 */
	public function compileQuery($sql, $binds)
	{
		$pos = 0;
		foreach ((array) $binds as $value) {
			if (false === $pos = strpos($sql, '?', $pos)) {
				break;
			}
			$value = $this->escape($value);
			$sql = substr($sql, 0, $pos) . $value . substr($sql, $pos + 1);
			$pos += strlen($value);
		}
		return $sql;
	}

	/**
	 * Escapes any input value.
	 *
	 * @param  mixed  $value  Value to escape.
	 * @return string
	 */
	public function escape($value)
	{
		switch (gettype($value)) {
			case 'string':
				$value = '\'' . mysqli_real_escape_string($this->link, $value) . '\'';
				break;
			case 'boolean':
				$value = (int) $value;
				break;
			case 'double':
				$value = sprintf('%F', $value);
				break;
			default:
				$value = (null === $value) ? 'NULL' : $value;
		}
		return (string) $value;
	}

	/**
	 * Escapes string.
	 *
	 * @param  string  $string  String to escape.
	 * @return string
	 */
	public function escapeString($value)
	{
		return mysqli_real_escape_string($this->link, $value);
	}

	/**
	 * Runs an insert query and returns the result.
	 *
	 * @param  string  $table   Table to insert values.
	 * @param  string  $values  Values.
	 * @return integer
	 * @throws DatabaseException
	 */
	public function insert($table, array $values)
	{
		$sql = 'INSERT INTO `' . $table . '` (`' . implode('`, `', array_keys($values)) . '`) VALUES ('
		     . implode(', ', array_map(array($this, 'escape'), array_values($values))) . ')';
		return $this->query($sql)->insertId();
	}

	/**
	 * Runs an update query and returns the result.
	 *
	 * @param  string  $table  Table to insert values.
	 * @param  array   $values Values.
	 * @param  array   $keys   Primary keys (from values).
	 * @return integer
	 * @throws DatabaseException
	 */
	public function update($table, array $values, array $keys)
	{
		$sql = 'UPDATE `' . $table . '` SET ';
		$i = 0;
		foreach ($values as $column => $value) {
			$sql .= (0 === $i++ ? '' : ', ') . '`' . $column . '` = ' . $this->escape($value);
		}
		$sql .=  ' WHERE ';
		$i = 0;
		foreach ($keys as $column => $value) {
			$sql .= (0 === $i++ ? '' : ' AND ') . '`' . $column . '` = ' . $this->escape($value);
		}
		return $this->query($sql)->count();
	}

	/**
	 * Escapes values and joins associative array.
	 *
	 * E.g. $db->values(array('a' => null, 'b' => 1, 'c' => 'DATE()'))
	 *      "`a` = NULL, `b` = 1, `c` = 'DATE()'"
	 *
	 * @param  string  $values The associative array of values to implode.
	 * @return string
	 */
	public function values(array $values)
	{
		$result = '';
		$i = 0;
		foreach ($values as $column => $value) {
			$result .= (0 === $i++ ? '' : ', ') . '`' . $column . '` = ' . $this->escape($value);
		}
		return $result;
	}

	/**
	 * Escapes values and join array of elements.
	 *
	 * E.g. $db->implode(', alias = ', array('a', 'b', 'c'))
	 *      "'a', alias = 'b', alias = 'c'"
	 *
	 * @param   string  $glue    Glue string.
	 * @param   string  $pieces  The array of strings to implode.
	 * @return  string
	 */
	public function implode($glue, array $pieces)
	{
		return implode($glue, array_map(array($this, 'escape'), $pieces));
	}

	/**
	 * Returns the last query run.
	 *
	 * @return string
	 */
	public function lastQuery()
	{
		return $this->lastQuery;
	}
}

class Database_Result implements Iterator, Countable
{
	/**
	 * @var  MySQLi  Database connection.
	 */
	protected $link;
	
	/**
	 * @var  mixed  Query result.
	 */
	protected $result;
	
	/**
	 * @var  object  Current row.
	 */
	protected $row;
	
	/**
	 * @var  integer  Rows counter.
	 */
	protected $counter = 0;

	/**
	 * Sets up the result variables.
	 *
	 * @param  object    Database connection.
	 * @param  resource  Query result.
	 * @return Database_Result
	 */
	public function __construct($link, $result)
	{
		$this->link   = $link;
		$this->result = $result;
	}
	
	/**
	 * Returns an array of associative arrays holding result rows.
	 *
	 * @return array
	 */
	public function fetchAll()
	{
		$result = array();
		while ($row = mysqli_fetch_assoc($this->result)) {
			$result[] = $row;
		}
		return $result;
	}

	/**
	 * Destructor. Frees the result.
	 *
	 * @return void
	 */
	public function __destruct()
	{
		if (is_object($this->result)) {
			mysqli_free_result($this->result);
		}
	}
	
	/**
	 * Returns the last insert id.
	 *
	 * @return integer
	 */
	public function insertId()
	{
		return mysqli_insert_id($this->link);
	}

	/**
	 * Fetches one row as array.
	 *
	 * @return mixed
	 */
	public function fetch()
	{
		$this->counter++;
		return $this->row = mysqli_fetch_assoc($this->result);
	}

	/**
	 * Countable: count
	 */
	public function count()
	{
		return is_object($this->result) ? mysqli_num_rows($this->result) : mysqli_affected_rows($this->link);
	}

	/**
	 * Iterator: current
	 */
	public function current()
	{
		return $this->row;
	}

	/**
	 * Iterator: key
	 */
	public function key()
	{
		return $this->counter;
	}

	/**
	 * Iterator: next
	 */
	public function next()
	{
		$this->fetch();
	}

	/**
	 * Iterator: rewind
	 */
	public function rewind()
	{
		$this->counter = 0;
		mysqli_data_seek($this->result, 0);
		$this->row = mysqli_fetch_assoc($this->result);
	}

	/**
	 * Iterator: valid
	 */
	public function valid()
	{
		return $this->row !== null;
	}
}
